Skip to content

Conversation

@WanjohiSammy
Copy link
Member

@WanjohiSammy WanjohiSammy commented Aug 5, 2025

Issues

This pull request fixes #3244, fixes #3226, fixes #3178

Description

OData queries fail when filtering against Flags enum properties using combined enum values, whether specified as string representations ('Premium,Loyal') or numeric equivalents (66).

The current implementation in MetadataBindingUtils.cs does not properly handle:

  • Combined flag values in string format (e.g., 'Premium,Loyal')
  • Numeric representations of combined flags (e.g., 66 for Premium | Loyal)

Changes Made

  • Enhanced enum value parsing logic in MetadataBindingUtils.cs
  • Added support for comma-separated flag combinations in string format
  • Implemented numeric value parsing for combined flags
  • Updated validation logic to recognize valid flag combinations

Query Patterns Now Supported

  • /customers?filter=Type eq 'Premium,Loyal'
  • /customers?filter=Type eq 66
  • /customers?filter=Type in ('Premium', 'VIP,Regular,Returning')
  • /customers?filter=Type in (12, 44)
  • /customers?filter=Type in ('12', '44')

OData ABNF rules for enums

https://docs.oasis-open.org/odata/odata/v4.01/os/abnf/odata-abnf-construction-rules.txt
image

  • Rule 1: Enum literals follow the format qualifiedEnumTypeName'SQUOTE enumValue SQUOTE.

    • qualifiedEnumTypeName (e.g., Namespace.Color) is optional.
    • Example: Namespace.Color'Blue' or 'Blue'.
  • Rule 2: enumValue can be a single value or multiple comma-separated values (for flags).

    • Examples: 'Blue', 'Red,Green'.
  • Rule 3: Each singleEnumValue can be a named member (Red) or a numeric value (4).

    • Supports unknown or bitwise values.
  • Rule 4: Numeric values must be valid int64.

    • Example: '8' or 8.

Checklist (Uncheck if it is not completed)

  • Test cases added
  • Build and test with one-click build and test script passed

Additional work necessary

If documentation update is needed, please add "Docs Needed" label to the issue and provide details about the required document change in the issue.

Repository notes

Team members can start a CI build by adding a comment with the text /AzurePipelines run to a PR. A bot may respond indicating that there is no pipeline associated with the pull request. This can be ignored if the build is triggered.

Team members should not trigger a build this way for pull requests coming from forked repositories. They should instead trigger the build manually by setting the "branch" to refs/pull/{prId}/merge where {prId} is the ID of the PR.

@WanjohiSammy
Copy link
Member Author

/AzurePipelines run

@azure-pipelines
Copy link

No pipelines are associated with this pull request.

@WanjohiSammy
Copy link
Member Author

/AzurePipelines run

@azure-pipelines
Copy link

No pipelines are associated with this pull request.

@WanjohiSammy WanjohiSammy marked this pull request as ready for review August 7, 2025 09:55
@WanjohiSammy WanjohiSammy requested review from ElizabethOkerio, gathogojr, habbes and xuzhg and removed request for xuzhg August 7, 2025 09:55
@WanjohiSammy WanjohiSammy force-pushed the fix/3244-support-flags-enum branch from 8e03a95 to 2bfab4c Compare August 23, 2025 17:23
@WanjohiSammy
Copy link
Member Author

/AzurePipelines run

@azure-pipelines
Copy link

No pipelines are associated with this pull request.

@WanjohiSammy
Copy link
Member Author

/AzurePipelines run

{
// Sort members by descending flag value to prioritize composite flags
var members = enumType.Members
.OrderByDescending(m => m.Value.Value);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OrderBy is necessary? I cannot see any advantage to use the orderred members?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@xuzhg Thanks for pointing this out.

The use of OrderByDescending is necessary for correct flag parsing when dealing with composite enum values. This ensures the output string uses the most specific (composite) flag names when possible. Removing the ordering would break this logic for flags enums with composite members.

Reasoning

  • Flags enums can have members that represent combinations of other members (composite flags). For example:

    • Read = 1
    • Write = 2
    • ReadWrite = 3 (which is Read | Write)
  • If you process members in ascending order, you may match and consume the individual flags (Read, Write) before considering the composite flag (ReadWrite). This would prevent the composite flag from ever being matched.

  • By sorting in descending order, composite flags (with higher values) are considered first. This ensures that the largest possible flag combinations are matched before their individual components.

Example

Suppose value = 3 and members are:

  • Read = 1
  • Write = 2
  • ReadWrite = 3

If you process in ascending order:

  • Match Read (1), remaining = 2
  • Match Write (2), remaining = 0
  • ReadWrite (3) is never matched

If you process in descending order:
• Match ReadWrite (3), remaining = 0
• Done

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make the 'Members' orderred always then we don't need to do order again and again?

Copy link
Member Author

@WanjohiSammy WanjohiSammy Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

xuzhg
xuzhg previously approved these changes Oct 22, 2025
@WanjohiSammy
Copy link
Member Author

/AzurePipelines run

2 similar comments
@WanjohiSammy
Copy link
Member Author

/AzurePipelines run

@WanjohiSammy
Copy link
Member Author

/AzurePipelines run

@WanjohiSammy WanjohiSammy force-pushed the fix/3244-support-flags-enum branch from db5c55c to 9384bc6 Compare November 11, 2025 07:20
@WanjohiSammy
Copy link
Member Author

/AzurePipelines run

Comment on lines +93 to +94
Assert.Equal((AccessLevel.Write | AccessLevel.Execute | AccessLevel.Read), result.UserAccess);
Assert.Equal((AccessLevel.ReadWrite | AccessLevel.Execute), result.UserAccess);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand why 2 assert statements

Copy link
Member Author

@WanjohiSammy WanjohiSammy Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One is the composite of the other. For example:

// AccessLevel.ReadWrite is a composite of AccessLevel.Write | AccessLevel.Read
AccessLevel.Write | AccessLevel.Execute | AccessLevel.Read == AccessLevel.ReadWrite | AccessLevel.Execute

// Where
 AccessLevel.ReadWrite == AccessLevel.Write | AccessLevel.Read

Comment on lines +107 to +108
Assert.Equal((AccessLevel.Write | AccessLevel.Execute | AccessLevel.Read), enumResult[0]);
Assert.Equal((AccessLevel.ReadWrite | AccessLevel.Execute), enumResult[0]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand why 2 assert statements

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/// <returns>A comma-separated string of flag names corresponding to the set bits in the specified value. Returns null otherwise.</returns>
public static string ParseFlagsFromIntegralValue(this IEdmEnumType enumType, long value)
{
var result = new List<string>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
var result = new List<string>();
List<string> result = new List<string>();

var result = new List<string>();
long remaining = value;

for (int index = enumType.Members.Count() - 1; index >= 0; index--)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling Members.Count() and Members.ElementAt(index) in a loop can turn this into an O(n^2) operation.
I'd suggest you first call .ToList():

List<IEdmEnumMember> members = enumType.Members.ToList();

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After doing the above, you can also avoid the string.Reverse call you're doing at the end by iterating the elements in the order you intend to have them in.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suppose value = 3 and members are:

  • Read = 1
  • Write = 2
  • ReadWrite = 3

If you process in ascending order:

  • Match Read (1), remaining = 2
  • Match Write (2), remaining = 0
  • ReadWrite (3) is never matched

If you process in descending order:

  • Match ReadWrite (3), remaining = 0
  • Done

In general, iterating in declaration order would allow us to avoid the Reverse call. However, for flag enums that include composite members (e.g., ReadWrite = 3), processing in ascending order would cause the individual flags (Read = 1, Write = 2) to consume the bits before the composite member is checked, so the composite would never match.

By iterating in descending order (from highest to lowest value), we ensure that composite members are matched first, which is necessary for correct flag decomposition. The Reverse at the end is then required to present the result in declaration order. This approach ensures correct handling for all flag enum scenarios, including those with composite members.

{
IEdmEnumMember member = enumType.Members.ElementAt(index);
long flagValue = Convert.ToInt64(member.Value.Value);
if (flagValue != 0 && (remaining & flagValue) == flagValue)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the enum defines a zero-valued member (often "None"), will it not be excluded by this condition? Think about how that should be handled

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the current implementation, a zero-valued member is not excluded. It is explicitly handled at the start of the method:

// Special handling for 0: return the name of the member whose value is 0 (e.g., "None"), if present.
// In Flags Enum, 0 typically represents no flags set.
if (value == 0)
{
    return enumType.Members.FirstOrDefault(m => Convert.ToUInt64(m.Value.Value) == 0)?.Name;
}

Basically, in a [Flags] enum, 0 conventionally means "no flags set" and is not a bitwise flag.

We cannot handle 0-valued members inside the main loop because 0 does not represent any set bit in a flags value. Including 0 in the loop would cause it to match any value (since (remaining & 0) == 0 is always true), which is incorrect. Therefore, 0-valued members must be handled separately before the loop to ensure correct and conventional behavior.

public static string ParseFlagsFromIntegralValue(this IEdmEnumType enumType, long value)
{
var result = new List<string>();
long remaining = value;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should opt for ulong here to keep bitwise match clean and predictable even if a value is negative (not sure if OData supports use of enum members with negative integral values)

unchecked
{
    ulong remaining = (ulong)value;
    // ...
}

Copy link
Member Author

@WanjohiSammy WanjohiSammy Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have udpated it to use ulong:

ulong remaining = unchecked((ulong)value);

@WanjohiSammy
Copy link
Member Author

/AzurePipelines run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants